/* global Highcharts module window:true */
(function (factory) {
    if (typeof module === 'object' && module.exports) {
        module.exports = factory;
    } else {
        factory(Highcharts);
    }
}(function (HC) {
    'use strict';
    /**
     * Grouped Categories v1.1.2 (2017-02-07)
     *
     * (c) 2012-2016 Black Label
     *
     * License: Creative Commons Attribution (CC)
     */

    /* jshint expr:true, boss:true */
    var UNDEFINED = void 0,
        mathRound = Math.round,
        mathMin = Math.min,
        mathMax = Math.max,
        merge = HC.merge,
        pick = HC.pick,
        each = HC.each,
        // #74, since Highcharts 4.1.10 HighchartsAdapter is only provided by the Highcharts Standalone Framework
        inArray = (window.HighchartsAdapter && window.HighchartsAdapter.inArray) || HC.inArray,

        // cache prototypes
        axisProto = HC.Axis.prototype,
        tickProto = HC.Tick.prototype,

        // cache original methods
        protoAxisInit = axisProto.init,
        protoAxisRender = axisProto.render,
        protoAxisSetCategories = axisProto.setCategories,
        protoTickGetLabelSize = tickProto.getLabelSize,
        protoTickAddLabel = tickProto.addLabel,
        protoTickDestroy = tickProto.destroy,
        protoTickRender = tickProto.render;

    function deepClone(thing) {
        return JSON.parse(JSON.stringify(thing));
    }

    function Category(obj, parent) {
        this.userOptions = deepClone(obj);
        this.name = obj.name || obj;
        this.parent = parent;

        return this;
    }

    Category.prototype.toString = function () {
        var parts = [],
            cat = this;

        while (cat) {
            parts.push(cat.name);
            cat = cat.parent;
        }

        return parts.join(', ');
    };

    // returns sum of an array
    function sum(arr) {
        var l = arr.length,
            x = 0;

        while (l--) {
            x += arr[l];
        }

        return x;
    }

    // Adds category leaf to array
    function addLeaf(out, cat, parent) {
        out.unshift(new Category(cat, parent));

        while (parent) {
            parent.leaves = parent.leaves ? (parent.leaves + 1) : 1;
            parent = parent.parent;
        }
    }

    // Builds reverse category tree
    function buildTree(cats, out, options, parent, depth) {
        var len = cats.length,
            cat;

        depth = depth ? depth : 0;
        options.depth = options.depth ? options.depth : 0;

        while (len--) {
            cat = cats[len];

            if (cat.categories) {
                if (parent) {
                    cat.parent = parent;
                }
                buildTree(cat.categories, out, options, cat, depth + 1);
            } else {
                addLeaf(out, cat, parent);
            }
        }
        options.depth = mathMax(options.depth, depth);
    }

    // Pushes part of grid to path
    function addGridPart(path, d, width) {
        // Based on crispLine from HC (#65)
        if (d[0] === d[2]) {
            d[0] = d[2] = mathRound(d[0]) - (width % 2 / 2);
        }
        if (d[1] === d[3]) {
            d[1] = d[3] = mathRound(d[1]) + (width % 2 / 2);
        }

        path.push(
            'M',
            d[0], d[1],
            'L',
            d[2], d[3]
        );
    }

    // Returns tick position
    function tickPosition(tick, pos) {
        return tick.getPosition(tick.axis.horiz, pos, tick.axis.tickmarkOffset);
    }

    function walk(arr, key, fn) {
        var l = arr.length,
            children;

        while (l--) {
            children = arr[l][key];

            if (children) {
                walk(children, key, fn);
            }
            fn(arr[l]);
        }
    }

    //
    // Axis prototype
    //

    axisProto.init = function (chart, options) {
        // default behaviour
        protoAxisInit.call(this, chart, options);

        if (typeof options === 'object' && options.categories) {
            this.setupGroups(options);
        }
    };

    // setup required axis options
    axisProto.setupGroups = function (options) {
        var categories = deepClone(options.categories),
            reverseTree = [],
            stats = {},
            labelOptions = this.options.labels,
            userAttr = labelOptions.groupedOptions,
            css = labelOptions.style;

        // build categories tree
        buildTree(categories, reverseTree, stats);

        // set axis properties
        this.categoriesTree = categories;
        this.categories = reverseTree;
        this.isGrouped = stats.depth !== 0;
        this.labelsDepth = stats.depth;
        this.labelsSizes = [];
        this.labelsGridPath = [];
        this.tickLength = options.tickLength || this.tickLength || null;
        // #66: tickWidth for x axis defaults to 1, for y to 0
        this.tickWidth = pick(options.tickWidth, this.isXAxis ? 1 : 0);
        this.directionFactor = [-1, 1, 1, -1][this.side];
        this.options.lineWidth = pick(options.lineWidth, 1);
        // #85: align labels vertically
        this.groupFontHeights = [];
        for (var i = 0; i <= stats.depth; i++) {
            var hasOptions = userAttr && userAttr[i - 1],
                mergedCSS = hasOptions && userAttr[i - 1].style ? merge(css, userAttr[i - 1].style) : css;
            this.groupFontHeights[i] = Math.round(this.chart.renderer.fontMetrics(mergedCSS ? mergedCSS.fontSize : 0).b * 0.3);
        }
    };


    axisProto.render = function () {
        // clear grid path
        if (this.isGrouped) {
            this.labelsGridPath = [];
        }

        // cache original tick length
        if (this.originalTickLength === UNDEFINED) {
            this.originalTickLength = this.options.tickLength;
        }

        // use default tickLength for not-grouped axis
        // and generate grid on grouped axes,
        // use tiny number to force highcharts to hide tick
        this.options.tickLength = this.isGrouped ? 0.001 : this.originalTickLength;

        protoAxisRender.call(this);

        if (!this.isGrouped) {
            if (this.labelsGrid) {
                this.labelsGrid.attr({
                    visibility: 'hidden'
                });
            }
            return false;
        }

        var axis = this,
            options = axis.options,
            top = axis.top,
            left = axis.left,
            right = left + axis.width,
            bottom = top + axis.height,
            visible = axis.hasVisibleSeries || axis.hasData,
            depth = axis.labelsDepth,
            grid = axis.labelsGrid,
            horiz = axis.horiz,
            d = axis.labelsGridPath,
            i = options.drawHorizontalBorders === false ? (depth + 1) : 0,
            offset = axis.opposite ? (horiz ? top : right) : (horiz ? bottom : left),
            tickWidth = axis.tickWidth,
            part;

        if (axis.userTickLength) {
            depth -= 1;
        }

        // render grid path for the first time
        if (!grid) {
            grid = axis.labelsGrid = axis.chart.renderer.path()
                .attr({
                    // #58: use tickWidth/tickColor instead of lineWidth/lineColor:
                    strokeWidth: tickWidth, // < 4.0.3
                    'stroke-width': tickWidth, // 4.0.3+ #30
                    stroke: options.tickColor || '' // for styled mode (tickColor === undefined)
                })
                .add(axis.axisGroup);
            // for styled mode - add class
            if (!options.tickColor) {
                grid.addClass('highcharts-tick');
            }
        }

        // go through every level and draw horizontal grid line
        while (i <= depth) {
            offset += axis.groupSize(i);

            part = horiz ?
                [left, offset, right, offset] :
                [offset, top, offset, bottom];

            addGridPart(d, part, tickWidth);
            i++;
        }

        // draw grid path
        grid.attr({
            d: d,
            visibility: visible ? 'visible' : 'hidden'
        });

        axis.labelGroup.attr({
            visibility: visible ? 'visible' : 'hidden'
        });


        walk(axis.categoriesTree, 'categories', function (group) {
            var tick = group.tick;

            if (!tick) {
                return false;
            }
            if (tick.startAt + tick.leaves - 1 < axis.min || tick.startAt > axis.max) {
                tick.label.hide();
                tick.destroyed = 0;
            } else {
                tick.label.attr({
                    visibility: visible ? 'visible' : 'hidden'
                });
            }
            return true;
        });
        return true;
    };

    axisProto.setCategories = function (newCategories, doRedraw) {
        if (this.categories) {
            this.cleanGroups();
        }
        this.setupGroups({
            categories: newCategories
        });
        this.categories = this.userOptions.categories = newCategories;
        protoAxisSetCategories.call(this, this.categories, doRedraw);
    };

    // cleans old categories
    axisProto.cleanGroups = function () {
        var ticks = this.ticks,
            n;

        for (n in ticks) {
            if (ticks[n].parent) {
                delete ticks[n].parent;
            }
        }
        walk(this.categoriesTree, 'categories', function (group) {
            var tick = group.tick;

            if (!tick) {
                return false;
            }
            tick.label.destroy();

            each(tick, function (v, i) {
                delete tick[i];
            });
            delete group.tick;

            return true;
        });
        this.labelsGrid = null;
    };

    // keeps size of each categories level
    axisProto.groupSize = function (level, position) {
        var positions = this.labelsSizes,
            direction = this.directionFactor,
            groupedOptions = this.options.labels.groupedOptions ? this.options.labels.groupedOptions[level - 1] : false,
            userXY = 0;

        if (groupedOptions) {
            if (direction === -1) {
                userXY = groupedOptions.x ? groupedOptions.x : 0;
            } else {
                userXY = groupedOptions.y ? groupedOptions.y : 0;
            }
        }

        if (position !== UNDEFINED) {
            positions[level] = mathMax(positions[level] || 0, position + 10 + Math.abs(userXY));
        }

        if (level === true) {
            return sum(positions) * direction;
        } else if (positions[level]) {
            return positions[level] * direction;
        }

        return 0;
    };

    //
    // Tick prototype
    //

    // Override methods prototypes
    tickProto.addLabel = function () {
        var category;

        protoTickAddLabel.call(this);

        if (!this.axis.categories || !(category = this.axis.categories[this.pos])) {
            return false;
        }

        // set label text - but applied after formatter #46
        if (this.label) {
            this.label.attr('text', this.axis.labelFormatter.call({
                axis: this.axis,
                chart: this.axis.chart,
                isFirst: this.isFirst,
                isLast: this.isLast,
                value: category.name
            }));
        }

        // create elements for parent categories
        if (this.axis.isGrouped && this.axis.options.labels.enabled) {
            this.addGroupedLabels(category);
        }
        return true;
    };

    // render ancestor label
    tickProto.addGroupedLabels = function (category) {
        var tick = this,
            axis = this.axis,
            chart = axis.chart,
            options = axis.options.labels,
            useHTML = options.useHTML,
            css = options.style,
            userAttr = options.groupedOptions,
            attr = {
                align: 'center',
                rotation: options.rotation,
                x: 0,
                y: 0
            },
            size = axis.horiz ? 'height' : 'width',
            depth = 0,
            label;


        while (tick) {
            if (depth > 0 && !category.tick) {
                // render label element
                this.value = category.name;
                var name = options.formatter ? options.formatter.call(this, category) : category.name,
                    hasOptions = userAttr && userAttr[depth - 1],
                    mergedAttrs = hasOptions ? merge(attr, userAttr[depth - 1]) : attr,
                    mergedCSS = hasOptions && userAttr[depth - 1].style ? merge(css, userAttr[depth - 1].style) : css;

                // #63: style is passed in CSS and not as an attribute
                delete mergedAttrs.style;

                label = chart.renderer.text(name, 0, 0, useHTML)
                    .attr(mergedAttrs)
                    .css(mergedCSS)
                    .add(axis.labelGroup);

                // tick properties
                tick.startAt = this.pos;
                tick.childCount = category.categories.length;
                tick.leaves = category.leaves;
                tick.visible = this.childCount;
                tick.label = label;
                tick.labelOffsets = {
                    x: mergedAttrs.x,
                    y: mergedAttrs.y
                };

                // link tick with category
                category.tick = tick;
            }

            // set level size, #93
            if (tick) {
                axis.groupSize(depth, tick.label.getBBox()[size]);
            }

            // go up to the parent category
            category = category.parent;

            if (category) {
                tick = tick.parent = category.tick || {};
            } else {
                tick = null;
            }

            depth++;
        }
    };

    // set labels position & render categories grid
    tickProto.render = function (index, old, opacity) {
        protoTickRender.call(this, index, old, opacity);

        var treeCat = this.axis.categories[this.pos];

        if (!this.axis.isGrouped || !treeCat || this.pos > this.axis.max) {
            return;
        }

        var tick = this,
            group = tick,
            axis = tick.axis,
            tickPos = tick.pos,
            isFirst = tick.isFirst,
            max = axis.max,
            min = axis.min,
            horiz = axis.horiz,
            grid = axis.labelsGridPath,
            size = axis.groupSize(0),
            tickWidth = axis.tickWidth,
            xy = tickPosition(tick, tickPos),
            start = horiz ? xy.y : xy.x,
            baseLine = axis.chart.renderer.fontMetrics(axis.options.labels.style ? axis.options.labels.style.fontSize : 0).b,
            depth = 1,
            reverseCrisp = ((horiz && xy.x === axis.pos + axis.len) || (!horiz && xy.y === axis.pos)) ? -1 : 0, // adjust grid lines for edges
            gridAttrs,
            lvlSize,
            minPos,
            maxPos,
            attrs,
            bBox;

        // render grid for "normal" categories (first-level), render left grid line only for the first category
        if (isFirst) {
            gridAttrs = horiz ?
                [axis.left, xy.y, axis.left, xy.y + axis.groupSize(true)] : axis.isXAxis ?
                    [xy.x, axis.top, xy.x + axis.groupSize(true), axis.top] : [xy.x, axis.top + axis.len, xy.x + axis.groupSize(true), axis.top + axis.len];

            addGridPart(grid, gridAttrs, tickWidth);
        }

        if (horiz && axis.left < xy.x) {
            addGridPart(grid, [xy.x - reverseCrisp, xy.y, xy.x - reverseCrisp, xy.y + size], tickWidth);
        } else if (!horiz && axis.top <= xy.y) {
            addGridPart(grid, [xy.x, xy.y + reverseCrisp, xy.x + size, xy.y + reverseCrisp], tickWidth);
        }

        size = start + size;

        function fixOffset(tCat) {
            var ret = 0;
            if (isFirst) {
                ret = inArray(tCat.name, tCat.parent.categories);
                ret = ret < 0 ? 0 : ret;
                return ret;
            }
            return ret;
        }


        while (group.parent) {
            group = group.parent;

            var fix = fixOffset(treeCat),
                userX = group.labelOffsets.x,
                userY = group.labelOffsets.y;

            minPos = tickPosition(tick, mathMax(group.startAt - 1, min - 1));
            maxPos = tickPosition(tick, mathMin(group.startAt + group.leaves - 1 - fix, max));
            bBox = group.label.getBBox(true);
            lvlSize = axis.groupSize(depth);
            // check if on the edge to adjust
            reverseCrisp = ((horiz && maxPos.x === axis.pos + axis.len) || (!horiz && maxPos.y === axis.pos)) ? -1 : 0;

            attrs = horiz ? {
                x: (minPos.x + maxPos.x) / 2 + userX,
                y: size + axis.groupFontHeights[depth] + lvlSize / 2 + userY / 2
            } : {
                x: size + lvlSize / 2 + userX,
                y: (minPos.y + maxPos.y - bBox.height) / 2 + baseLine + userY
            };

            if (!isNaN(attrs.x) && !isNaN(attrs.y)) {
                group.label.attr(attrs);

                if (grid) {
                    if (horiz && axis.left < maxPos.x) {
                        addGridPart(grid, [maxPos.x - reverseCrisp, size, maxPos.x - reverseCrisp, size + lvlSize], tickWidth);
                    } else if (!horiz && axis.top <= maxPos.y) {
                        addGridPart(grid, [size, maxPos.y + reverseCrisp, size + lvlSize, maxPos.y + reverseCrisp], tickWidth);
                    }
                }
            }

            size += lvlSize;
            depth++;
        }
    };

    tickProto.destroy = function () {
        var group = this.parent;

        while (group) {
            group.destroyed = group.destroyed ? (group.destroyed + 1) : 1;
            group = group.parent;
        }

        protoTickDestroy.call(this);
    };

    // return size of the label (height for horizontal, width for vertical axes)
    tickProto.getLabelSize = function () {
        if (this.axis.isGrouped === true) {
            // #72, getBBox might need recalculating when chart is tall
            var size = protoTickGetLabelSize.call(this) + 10,
                topLabelSize = this.axis.labelsSizes[0];
            if (topLabelSize < size) {
                this.axis.labelsSizes[0] = size;
            }
            return sum(this.axis.labelsSizes);
        }
        return protoTickGetLabelSize.call(this);
    };

}));